StatefulWidget의 상태가 State 객체에 있는 이유

StatefulWidget의 상태가 State 객체에 있는 이유

한눈에 보기

StatefulWidget은 부모가 매 rebuild마다 새로 만들 수 있는 불변 configuration이고, State는 같은 runtimeTypekey로 유지되는 Element 위치에 연결된 장기 객체다. 이 분리 덕분에 부모는 최신 설정을 새 Widget으로 전달하면서도 입력값, animation controller, subscription 같은 로컬 상태를 보존할 수 있다.

StatefulWidget이라는 이름만 보면 Widget 내부 필드가 바뀔 것처럼 느껴진다. 실제로는 StatefulWidget도 다른 Widget과 마찬가지로 불변이다.

class Counter extends StatefulWidget {
  const Counter({
    super.key,
    required this.step,
  });

  final int step;

  @override
  State<Counter> createState() => _CounterState();
}

부모가 step: 1에서 step: 5로 바꾸면 기존 Counter를 수정하지 않는다. 새로운 Counter configuration을 만든다. 반면 현재 count처럼 사용자 상호작용을 통해 변하는 값은 _CounterState에 둔다.

class _CounterState extends State<Counter> {
  int count = 0;

  void increment() {
    setState(() {
      count += widget.step;
    });
  }
}

왜 하나의 class에 final step과 mutable count를 함께 두지 않았을까? 답은 두 값의 소유자와 수명이 다르기 때문이다.

목차

Configuration과 State의 수명이 다르다

부모 Widget은 build할 때 자식 configuration을 새로 만든다.

Counter(step: settings.counterStep)

settings.counterStep이 바뀌거나 부모가 다른 이유로 rebuild되면 새 Counter 인스턴스가 전달될 수 있다. StatefulWidget 안에 mutable count를 두었다면 configuration 교체 때 어느 값을 유지해야 할지 모호해진다.

Flutter는 역할을 분리한다.

객체 책임 누가 변경하는가 일반적인 수명
StatefulWidget 부모가 전달한 최신 설정 부모가 새 인스턴스 생성 짧음
State 해당 위치의 mutable 상태와 리소스 State 자체와 외부 이벤트 Element와 함께 지속
Element Widget과 State 연결 framework 트리 위치가 유지되는 동안

Widget field에는 다음과 같은 입력이 들어간다.

State에는 다음과 같은 값이 들어갈 수 있다.

“변하면 State, 안 변하면 Widget”만으로 분류하면 부족하다. 값이 변하더라도 부모가 소유해 props처럼 내려보내는 값은 Widget configuration이다. 핵심은 변경 가능성보다 누가 진실의 원천인가다.

State는 Widget이 아니라 트리 위치에 연결된다

createState()는 StatefulWidget을 특정 위치에 inflate할 때 호출된다. 같은 Widget 인스턴스를 두 위치에 사용하면 각각 별도 State가 만들어질 수 있다.

const counter = Counter(step: 1);

Column(
  children: [
    counter,
    counter,
  ],
)

두 Counter는 같은 configuration 객체를 사용하지만 화면 위치가 다르므로 서로 다른 count를 가질 수 있다.

flowchart TD
    W["Counter Widget
step = 1"] --> E1["Element 위치 A"] W --> E2["Element 위치 B"] E1 --> S1["State A
count = 2"] E2 --> S2["State B
count = 7"]

State는 Widget 객체 identity에 붙어 있는 것이 아니라 StatefulElement가 나타내는 위치에 연결된다. 같은 위치에 호환되는 새 Widget이 오면 State는 유지되고 state.widget이 최신 configuration을 가리키게 된다.

반대로 Widget을 트리에서 제거하고 나중 frame에 다시 넣으면 새 State가 생성된다. GlobalKey를 사용해 같은 frame 안에서 subtree를 옮기는 특별한 경우에는 State를 함께 이동시킬 수 있지만, 일반적인 상태 관리 수단으로 남용하면 안 된다.

부모 rebuild 뒤에도 상태가 유지되는 과정

다음 부모가 step을 변경한다고 가정해 보자.

class CounterHost extends StatelessWidget {
  const CounterHost({
    super.key,
    required this.step,
  });

  final int step;

  @override
  Widget build(BuildContext context) {
    return Counter(step: step);
  }
}

처음에는 Counter(step: 1)이고 사용자가 count를 3까지 올렸다. 이후 부모가 Counter(step: 5)를 만든다.

같은 위치에서 두 Widget의 runtimeTypekey가 같으면 기존 StatefulElement가 새 Widget으로 update된다.

sequenceDiagram
    participant P as 부모
    participant E as StatefulElement
    participant O as 기존 Counter
    participant N as 새 Counter
    participant S as _CounterState

    P->>E: Counter(step: 5) 전달
    E->>E: type과 key 비교
    E->>S: widget을 새 configuration으로 교체
    E->>S: didUpdateWidget(oldWidget) 호출
    S->>S: count = 3 유지
    S->>S: build에서 widget.step = 5 사용

State 내부 count는 그대로 3이고 최신 widget.step은 5다. 다음 increment 결과는 8이 된다.

key를 바꾸면 identity가 달라져 기존 State가 폐기될 수 있다.

Counter(
  key: ValueKey(accountId),
  step: step,
)

계정이 바뀔 때 편집 상태를 초기화해야 한다면 account ID 기반 key가 의도를 잘 표현할 수 있다. 반대로 build마다 UniqueKey()를 생성하면 매번 State가 초기화된다.

State lifecycle을 흐름으로 이해하기

State lifecycle은 callback 이름을 외우기보다 configuration, dependency, tree membership 변화로 나누면 이해하기 쉽다.

stateDiagram-v2
    [*] --> Created: createState
    Created --> Mounted: context 연결
    Mounted --> Initialized: initState
    Initialized --> DependenciesReady: didChangeDependencies
    DependenciesReady --> Building: build
    Building --> Building: setState / dependency 변경
    Building --> Updated: 새 호환 Widget
    Updated --> Building: didUpdateWidget 이후 build
    Building --> Inactive: deactivate
    Inactive --> Building: 같은 frame에 activate
    Inactive --> Defunct: dispose
    Defunct --> [*]

주요 callback의 책임은 다음과 같다.

callback 호출 의미 대표 작업
initState State당 최초 한 번 초기화 controller 생성, Widget 입력 기반 구독
didChangeDependencies 의존한 InheritedWidget 변경 locale·theme·provider 의존 갱신
didUpdateWidget 같은 State에 새 configuration 전달 old/new 입력 비교, 구독 교체
build 현재 상태로 UI 설명 빠르고 부수 효과 없는 Widget 구성
deactivate 현재 트리 위치에서 일시 제거 Element 관계 정리
dispose 영구 제거 소유 리소스 해제

hot reload의 reassemble과 앱 foreground/background lifecycle은 State의 mount lifecycle과 또 다른 개념이다. 화면에서 dispose되지 않았다고 앱이 항상 foreground인 것은 아니다.

initState에서 초기화할 것

initState는 framework가 생성한 각 State 객체에 대해 한 번 호출한다. State가 소유하는 controller를 만들거나 초기 subscription을 연결하기 적합하다.

class _SearchFieldState extends State<SearchField>
    with SingleTickerProviderStateMixin {
  late final TextEditingController _textController;
  late final AnimationController _animationController;

  @override
  void initState() {
    super.initState();

    _textController = TextEditingController(
      text: widget.initialQuery,
    );

    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 180),
    );
  }
}

여기서 initialQuery라는 이름은 이 값이 초기화할 때만 사용된다는 계약을 나타낸다. 부모가 나중에 새 initialQuery를 전달해도 controller text를 자동으로 덮어쓰지 않는다.

반대로 부모 configuration 변경을 항상 반영해야 한다면 didUpdateWidget 정책이 필요하다.

initState에서는 context.dependOnInheritedWidgetOfExactType을 통해 dependency를 만들지 않는다. 이에 의존하는 작업은 곧이어 호출되는 didChangeDependencies에서 수행한다.

@override
void initState() {
  super.initState();
  _subscription = widget.stream.listen(_onValue);
}

Widget field에서 받은 stream처럼 inherited dependency가 아닌 입력은 initState에서 구독할 수 있다. 다만 부모가 다른 stream을 전달할 수 있으므로 교체 처리도 작성해야 한다.

didChangeDependencies가 필요한 경우

Theme.of(context), Localizations.of(context), MediaQuery.of(context) 같은 호출은 ancestor의 InheritedWidget에 의존한다. 해당 값이 바뀌면 didChangeDependencies와 build가 다시 호출될 수 있다.

build에서 단순히 값을 읽는 것은 자연스럽다.

@override
Widget build(BuildContext context) {
  final locale = Localizations.localeOf(context);
  return Text('Locale: $locale');
}

dependency 변경 때 비싼 준비 작업을 다시 해야 한다면 didChangeDependencies를 사용할 수 있다.

Locale? _preparedLocale;

@override
void didChangeDependencies() {
  super.didChangeDependencies();

  final locale = Localizations.localeOf(context);
  if (_preparedLocale == locale) return;

  _preparedLocale = locale;
  _formatter = createFormatter(locale);
}

이 callback은 최초 initState 뒤에도 호출되므로 “변경 때만 호출”된다고 생각하면 안 된다. 이전 값을 보관하고 실제 변화 여부를 비교한다.

비동기 I/O를 여기서 시작할 때는 dependency가 연속으로 바뀌는 경우, 이전 작업 취소, 응답 순서, dispose 이후 완료를 함께 설계해야 한다.

didUpdateWidget에서 구독을 교체하기

부모가 전달한 ChangeNotifier, Stream, controller가 바뀔 수 있다면 old configuration에서 구독을 끊고 새 configuration에 연결해야 한다.

class PriceView extends StatefulWidget {
  const PriceView({
    super.key,
    required this.priceListenable,
  });

  final ValueListenable<int> priceListenable;

  @override
  State<PriceView> createState() => _PriceViewState();
}

class _PriceViewState extends State<PriceView> {
  @override
  void initState() {
    super.initState();
    widget.priceListenable.addListener(_onPriceChanged);
  }

  @override
  void didUpdateWidget(covariant PriceView oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.priceListenable == widget.priceListenable) {
      return;
    }

    oldWidget.priceListenable.removeListener(_onPriceChanged);
    widget.priceListenable.addListener(_onPriceChanged);
  }

  void _onPriceChanged() {
    setState(() {
      // 외부 Listenable의 값이 변경되었다.
    });
  }

  @override
  void dispose() {
    widget.priceListenable.removeListener(_onPriceChanged);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text('${widget.priceListenable.value}원');
  }
}

세 단계가 대칭을 이룬다.

  1. initState: 최초 객체 구독
  2. didUpdateWidget: 입력 객체가 바뀌면 이전 구독 해제 후 새 구독
  3. dispose: 현재 객체 구독 해제

didUpdateWidget 다음에는 framework가 build를 호출하므로 단지 새 widget 값을 화면에 반영하기 위해 setState를 다시 부를 필요는 없다. 별도의 내부 state를 동기적으로 조정해야 할 때만 변경한다.

새 구독만 추가하지 않는다

이전 notifier listener를 제거하지 않으면 보이지 않는 데이터 소스의 이벤트에도 계속 반응하고 State가 오래 참조되어 memory leak으로 이어질 수 있다.

deactivate와 dispose는 다르다

deactivate는 State가 현재 트리에서 제거될 때 호출되지만 같은 animation frame 안에 다른 위치로 다시 삽입될 수 있다. GlobalKey를 이용한 subtree 이동이 대표적이다.

따라서 대부분의 controller와 subscription은 deactivate가 아니라 dispose에서 최종 해제한다.

@override
void dispose() {
  _timer?.cancel();
  _subscription.cancel();
  _animationController.dispose();
  _textController.dispose();
  super.dispose();
}

dispose 이후 State는 다시 mount되지 않고 setState를 호출할 수 없다.

다만 운영체제가 프로세스를 강제 종료하면 dispose가 항상 호출된다고 보장할 수 없다. 중요한 사용자 데이터 저장을 dispose 하나에만 의존해서는 안 된다. 앱 lifecycle 관측과 명시적인 저장 시점을 별도로 설계한다.

deactivate는 ancestor나 다른 Element와 맺은 임시 관계를 끊는 고급 구현에서 주로 사용한다. 일반적인 controller 정리를 위해 관습적으로 override할 필요는 없다.

setState가 실제로 하는 일

setState callback은 즉시 동기적으로 실행되고 연결된 Element를 dirty로 표시해 build를 예약한다.

void increment() {
  setState(() {
    count += widget.step;
  });
}

callback을 async로 만들면 상태 변경이 언제 끝났는지 불분명해지므로 허용되지 않는다.

Future<void> save() async {
  final result = await repository.save(draft);

  if (!mounted) return;

  setState(() {
    lastSavedAt = result.savedAt;
  });
}

비동기 작업은 밖에서 끝내고 UI에 영향을 주는 동기 state 변경만 setState 안에 둔다.

setState의 직접 호출 비용은 작지만 간접 비용은 해당 subtree의 rebuild와 필요한 layout·paint다. 같은 frame에 의미 없는 중복 호출을 하지 않는다.

for (final item in items) {
  setState(() {
    selectedIds.add(item.id);
  });
}
setState(() {
  selectedIds.addAll(items.map((item) => item.id));
});

둘째 코드는 상태 변경 의도가 한 번에 보이고 불필요한 호출을 줄인다.

mounted 검사는 dispose 이후 setState 오류를 막는 최후의 경계다. 가능하면 timer, subscription, 요청 callback 자체를 dispose에서 취소해 쓸모없는 작업이 계속되지 않게 한다. 비동기 gap 이후 context 사용은 BuildContext를 비동기 구간 뒤에 사용할 때 주의점에서 더 자세히 다룬다.

State에 무엇을 두고 무엇을 밖으로 뺄까

모든 변경 값을 State에 넣으면 화면 수명과 데이터 수명이 강하게 결합된다.

상태 종류 적합한 위치
일시적인 UI 상태 펼침, focus, animation 진행 로컬 State
편집 중 draft 아직 제출하지 않은 form 값 로컬 State 또는 전용 form model
여러 화면 공유 상태 로그인 사용자, 장바구니 상위 state / state management
서버의 원본 데이터 상품, 주문 repository와 서버 상태 계층
영속 설정 테마, 알림 설정 storage + app state

다음 질문으로 판단할 수 있다.

State class에 repository 응답 전체와 비즈니스 규칙을 모두 넣기보다 화면 고유의 ephemeral state와 애플리케이션 state를 분리한다.

그렇다고 모든 bool expanded를 전역 store로 올리면 소유권이 오히려 흐려진다. state는 필요한 가장 가까운 위치에 두고 공유 요구가 생길 때 올린다.

리소스 소유권을 명시하기

Controller를 Widget 밖에서 전달받을 수도 있고 State가 직접 만들 수도 있다. 누가 dispose할지 계약이 필요하다.

State가 소유하는 경우

class _EditorState extends State<Editor> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

생성한 State가 해제한다.

부모가 소유한 객체를 빌리는 경우

class Editor extends StatefulWidget {
  const Editor({
    super.key,
    required this.controller,
  });

  final TextEditingController controller;
}

일반적으로 자식 State는 전달받은 controller를 dispose하지 않는다. 부모가 다른 controller로 교체할 수 있다면 listener만 old/new 객체에서 옮긴다.

optional controller를 받는 컴포넌트는 내부 소유 여부를 명시적으로 추적할 수 있다.

late TextEditingController _effectiveController;
late bool _ownsController;

void _configureController() {
  _ownsController = widget.controller == null;
  _effectiveController =
      widget.controller ?? TextEditingController();
}

업데이트 때 외부→내부, 내부→외부 전환까지 처리해야 하므로 구현 복잡성이 커진다. API 편의성과 lifecycle 비용을 함께 고려한다.

소유권 규칙

“누가 생성했는가?”, “누가 listener를 붙였는가?”, “누가 dispose 또는 removeListener하는가?”를 각각 구분해 문서화한다.

자주 발생하는 lifecycle 버그

Widget field를 State에 한 번 복사하고 갱신하지 않는다

late String title;

@override
void initState() {
  super.initState();
  title = widget.title;
}

부모의 최신 title을 항상 반영하려는 목적이라면 복사하지 않고 build에서 widget.title을 읽는다. 초기 draft로만 쓸 목적이라면 이름과 문서로 그 계약을 드러낸다.

build에서 controller를 만든다

@override
Widget build(BuildContext context) {
  final controller = TextEditingController(text: widget.text);
  return TextField(controller: controller);
}

rebuild마다 controller가 바뀌어 selection과 composing state가 깨지고 dispose도 어렵다. State 수명에 맞춰 생성한다.

notifier 교체를 놓친다

initState에서만 listener를 붙이면 부모가 새 notifier를 내려보낼 때 이전 데이터에 계속 연결된다. didUpdateWidget에서 identity를 비교한다.

mounted 확인만 하고 작업은 방치한다

timer와 stream은 계속 실행되어 배터리와 CPU를 쓰고 State를 참조할 수 있다. dispose에서 원인을 취소하는 것이 우선이다.

GlobalKey로 모든 상태를 보존한다

GlobalKey는 subtree 이동과 외부 State 접근 같은 강한 기능을 제공하지만 전역 identity 관리 비용과 결합도를 만든다. 데이터 state 보존은 상위 model, route state, restoration 등 더 명확한 소유자를 검토한다.

실무 체크리스트

상태 위치

Lifecycle

비동기와 종료

마무리

StatefulWidget과 State가 분리된 이유는 문법적인 관습이 아니다. 부모가 전달하는 configuration과 화면 위치에 지속되는 mutable state의 수명이 다르기 때문이다.

부모는 rebuild할 때 최신 설정을 담은 새 StatefulWidget을 자유롭게 만든다. 같은 위치에서 type과 key가 유지되면 Element는 기존 State를 보존하면서 state.widget만 새 configuration으로 바꾼다. 그 결과 사용자 입력과 controller는 유지되고 부모의 최신 callback과 설정도 사용할 수 있다.

이 구조를 제대로 활용하려면 lifecycle에 맞춰 리소스를 관리해야 한다. 최초 구독은 initState, configuration 객체 교체는 didUpdateWidget, inherited dependency 변화는 didChangeDependencies, 최종 해제는 dispose가 담당한다. setState는 변경을 수행한 뒤 build를 예약할 뿐 비동기 작업이나 전역 상태 관리의 대체물이 아니다.

Widget은 지금 이 위치가 어떻게 구성되어야 하는지를 전달하고, State는 그 위치가 살아 있는 동안 무엇을 기억해야 하는지를 보관한다. 두 책임을 분리하면 rebuild가 잦아도 상태 수명과 외부 리소스 소유권을 예측할 수 있다.

관련 노트

참고 자료